Skip to content

Add grabEntityManager(), resetDoctrineManager() and grabContainer() - #248

Merged
TavoNiievez merged 3 commits into
Codeception:mainfrom
TavoNiievez:feature/entity-manager-and-container-accessors
Sep 4, 2026
Merged

Add grabEntityManager(), resetDoctrineManager() and grabContainer()#248
TavoNiievez merged 3 commits into
Codeception:mainfrom
TavoNiievez:feature/entity-manager-and-container-accessors

Conversation

@TavoNiievez

@TavoNiievez TavoNiievez commented Sep 4, 2026

Copy link
Copy Markdown
Member

Adds three accessors that dependent projects have been hand-writing for years. The counts below come from a survey of the test suites of 349 public repositories that depend on this module.

1. grabEntityManager() — 44 repositories wrote it themselves

_getEntityManager() is underscore-prefixed, so ModuleContainer excludes it from the Actor. Reaching the EntityManager meant a getModule('Symfony') reach-through, a grabService() call with a hardcoded id, or a custom Helper module. The corpus contains six different spellings of the same line, including four unrelated tutorial repos reading the public getModule('Doctrine2')->em property directly.

It delegates to _getEntityManager(), so it keeps resolving from the current container on every call and honours em_service. The service resolution moved into resolveEntityManager(), which also fixes a misleading message: the old one said "is not an instance of EntityManagerInterface" both when the service was missing entirely and when it was the wrong type.

2. resetDoctrineManager() — 41 repositories, the largest copy-paste cluster in the corpus

Ten repositories ship a byte-identical EntityManagerReset Helper module, with the same docblock — "Fix "The EntityManager is closed" after test failure" — and 31 more wire that class into a suite YAML.

This PR does not fix a live cross-test bug: 6cd2150 (#236) already did. Before that commit _getEntityManager() pinned the manager into permanentServices, so one failed flush left a closed EM that the connector re-injected into every subsequent test's container for the rest of the suite. Every repository in the corpus is pinned to ^3.1/^3.5, i.e. before that fix. What remains on main is a narrower window: a write after a failed flush within a single test, with no intervening request. So this gives those 41 projects a supported API instead of a copied Helper, and closes that window.

Semantics mirror doctrine-bundle's own private Registry::resetOrClearManager(): an open manager is cleared, a closed one is rebuilt. Recovery goes through Doctrine's registry, which swaps the lazy service in place so the application and module-doctrine see the reopened manager too. On Symfony 5.4 the entity manager service is not lazy (no LazyServiceDumper, no proxy-manager-bridge), and ManagerRegistry::resetService() throws there, so the client kernel is rebooted instead. doctrine.dbal.default_connection stays a permanent service in both paths, so the open test transaction survives.

_getEntityManager() applies the same registry recovery before handing the manager out. It is bounded: it only runs when isOpen() is already false, i.e. when the manager was going to throw anyway. Nothing is cached — the container remains the source of truth.

A module config key was considered and rejected: it would have to act in _before, where the container is milliseconds old and the manager is never closed yet.

3. grabContainer() — 41 repositories wrote it themselves

Same root cause: _getContainer() is underscore-prefixed. Most projects settled on $I->grabService('kernel')->getContainer(), which is subtly wrong — that is the application container, which cannot see private services, while _getContainer() returns test.service_container, which can. Tagged @part services alongside grabService().

Notes

  • No composer.json change. Doctrine\Persistence\ManagerRegistry is already present via doctrine/orm (require-dev) and ::class never autoloads; every Doctrine touch is guarded by interface_exists() + instanceof + try/catch.
  • The fixture app wires Doctrine by hand and had no registry to reset through, so it gains a minimal TestManagerRegistry, signature-compatible with doctrine/persistence 3.x and 4.x. The manager is rebuilt on the existing connection because the fixture database is in-memory, and the entity manager service is no longer shared so the container hands out the rebuilt one.

Verification

_getEntityManager() is underscore-prefixed, so ModuleContainer excludes it from
the Actor and it cannot be called as $I->_getEntityManager(). Reaching the
EntityManager from a test therefore meant a getModule('Symfony') reach-through,
a grabService() call with a hardcoded service id, or a custom Helper module.

grabEntityManager() exposes it on the Actor. It delegates to _getEntityManager(),
so it keeps resolving the manager from the current container on every call and
honours the em_service option.

Extract the service resolution into resolveEntityManager(), which also improves
the failure message: the previous one reported "is not an instance of
EntityManagerInterface" both when the service was missing entirely and when it
was the wrong type, without hinting that doctrine-bundle might not be installed
or that em_service might be misconfigured.
Doctrine closes the EntityManager when an exception escapes a flush(), and every
write after that throws EntityManagerClosed. Users worked around this by shipping
an EntityManagerReset helper module that resets the manager in _before; it is the
most copy-pasted workaround in the dependent ecosystem.

Add resetDoctrineManager() as the supported replacement: an open manager is
cleared, a closed one is rebuilt through Doctrine's registry, which swaps the
lazy service in place so the application and the Doctrine module see the reopened
manager too. When the registry cannot do it, because the manager service is not
lazy on Symfony 5.4 or the app has no DoctrineBundle, the client kernel is
rebooted instead. The DBAL connection stays a permanent service either way, so
the open test transaction survives.

_getEntityManager() applies the same registry recovery before handing the manager
out, so a single broken write no longer cascades through the rest of the test.
Nothing is cached; the container remains the source of truth.

The fixture app wires Doctrine by hand and had no registry to reset through, so
add a minimal TestManagerRegistry, rebuild the manager on the existing connection
(the fixture database is in-memory, reconnecting would drop the schema), and stop
sharing the entity manager service so the container hands out the rebuilt one.
_getContainer() is underscore-prefixed, so ModuleContainer excludes it from the
Actor. Tests that need the container reached it through a getModule('Symfony')
call in a custom Helper, or wrote $I->grabService('kernel')->getContainer().

That second form is subtly wrong: the kernel exposes the application container,
which cannot see private services, while _getContainer() returns Symfony's
test.service_container, which can. grabContainer() puts the correct one on the
Actor, tagged as part of the services part alongside grabService().
@TavoNiievez
TavoNiievez force-pushed the feature/entity-manager-and-container-accessors branch from e4b5e5e to b854515 Compare September 4, 2026 08:12
@TavoNiievez
TavoNiievez merged commit 53c37cc into Codeception:main Sep 4, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant